import * as fs from 'fs';
import * as path from 'path';
import * as temp from 'temp';
import * as yaml from 'js-yaml';
import { promisify } from 'util';
import * as grpc from '@grpc/grpc-js';
import { injectable, inject, named } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import {
  ConfigService,
  Config,
  NotificationServiceServer,
  Network,
} from '../common/protocol';
import { spawnCommand } from './exec-util';
import {
  MergeRequest,
  WriteRequest,
} from './cli-protocol/cc/arduino/cli/settings/v1/settings_pb';
import { SettingsServiceClient } from './cli-protocol/cc/arduino/cli/settings/v1/settings_grpc_pb';
import * as serviceGrpcPb from './cli-protocol/cc/arduino/cli/settings/v1/settings_grpc_pb';
import { ArduinoDaemonImpl } from './arduino-daemon-impl';
import { DefaultCliConfig, CLI_CONFIG } from './cli-config';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { deepClone } from '@theia/core';

const deepmerge = require('deepmerge');
const track = temp.track();

@injectable()
export class ConfigServiceImpl
  implements BackendApplicationContribution, ConfigService
{
  @inject(ILogger)
  @named('config')
  protected readonly logger: ILogger;

  @inject(EnvVariablesServer)
  protected readonly envVariablesServer: EnvVariablesServer;

  @inject(ArduinoDaemonImpl)
  protected readonly daemon: ArduinoDaemonImpl;

  @inject(NotificationServiceServer)
  protected readonly notificationService: NotificationServiceServer;

  protected config: Config;
  protected cliConfig: DefaultCliConfig | undefined;
  protected ready = new Deferred<void>();
  protected readonly configChangeEmitter = new Emitter<Config>();

  async onStart(): Promise<void> {
    await this.ensureCliConfigExists();
    this.cliConfig = await this.loadCliConfig();
    if (this.cliConfig) {
      const config = await this.mapCliConfigToAppConfig(this.cliConfig);
      if (config) {
        this.config = config;
        this.ready.resolve();
        return;
      }
    }
    this.fireInvalidConfig();
  }

  async getCliConfigFileUri(): Promise<string> {
    const configDirUri = await this.envVariablesServer.getConfigDirUri();
    return new URI(configDirUri).resolve(CLI_CONFIG).toString();
  }

  async getConfiguration(): Promise<Config> {
    await this.ready.promise;
    return this.config;
  }

  async setConfiguration(config: Config): Promise<void> {
    await this.ready.promise;
    if (Config.sameAs(this.config, config)) {
      return;
    }
    let copyDefaultCliConfig: DefaultCliConfig | undefined = deepClone(
      this.cliConfig
    );
    if (!copyDefaultCliConfig) {
      copyDefaultCliConfig = await this.getFallbackCliConfig();
    }
    const {
      additionalUrls,
      dataDirUri,
      downloadsDirUri,
      sketchDirUri,
      network,
      locale,
    } = config;
    copyDefaultCliConfig.directories = {
      data: FileUri.fsPath(dataDirUri),
      downloads: FileUri.fsPath(downloadsDirUri),
      user: FileUri.fsPath(sketchDirUri),
    };
    copyDefaultCliConfig.board_manager = {
      additional_urls: [...additionalUrls],
    };
    copyDefaultCliConfig.locale = locale || 'en';
    const proxy = Network.stringify(network);
    copyDefaultCliConfig.network = { proxy };
    const { port } = copyDefaultCliConfig.daemon;
    await this.updateDaemon(port, copyDefaultCliConfig);
    await this.writeDaemonState(port);

    this.config = deepClone(config);
    this.cliConfig = copyDefaultCliConfig;
    this.fireConfigChanged(this.config);
  }

  get cliConfiguration(): DefaultCliConfig | undefined {
    return this.cliConfig;
  }

  get onConfigChange(): Event<Config> {
    return this.configChangeEmitter.event;
  }

  async getVersion(): Promise<
    Readonly<{ version: string; commit: string; status?: string }>
  > {
    return this.daemon.getVersion();
  }

  async isInDataDir(uri: string): Promise<boolean> {
    return this.getConfiguration().then(({ dataDirUri }) =>
      new URI(dataDirUri).isEqualOrParent(new URI(uri))
    );
  }

  async isInSketchDir(uri: string): Promise<boolean> {
    return this.getConfiguration().then(({ sketchDirUri }) =>
      new URI(sketchDirUri).isEqualOrParent(new URI(uri))
    );
  }

  protected async loadCliConfig(): Promise<DefaultCliConfig | undefined> {
    const cliConfigFileUri = await this.getCliConfigFileUri();
    const cliConfigPath = FileUri.fsPath(cliConfigFileUri);
    try {
      const content = await promisify(fs.readFile)(cliConfigPath, {
        encoding: 'utf8',
      });
      const model = yaml.safeLoad(content) || {};
      // The CLI can run with partial (missing `port`, `directories`), the app cannot, we merge the default with the user's config.
      const fallbackModel = await this.getFallbackCliConfig();
      return deepmerge(fallbackModel, model) as DefaultCliConfig;
    } catch (error) {
      this.logger.error(
        `Error occurred when loading CLI config from ${cliConfigPath}.`,
        error
      );
    }
    return undefined;
  }

  protected async getFallbackCliConfig(): Promise<DefaultCliConfig> {
    const cliPath = await this.daemon.getExecPath();
    const throwawayDirPath = await new Promise<string>((resolve, reject) => {
      track.mkdir({}, (err, dirPath) => {
        if (err) {
          reject(err);
          return;
        }
        resolve(dirPath);
      });
    });
    await spawnCommand(`"${cliPath}"`, [
      'config',
      'init',
      '--dest-dir',
      `"${throwawayDirPath}"`,
    ]);
    const rawYaml = await promisify(fs.readFile)(
      path.join(throwawayDirPath, CLI_CONFIG),
      { encoding: 'utf-8' }
    );
    const model = yaml.safeLoad(rawYaml.trim());
    return model as DefaultCliConfig;
  }

  protected async ensureCliConfigExists(): Promise<void> {
    const cliConfigFileUri = await this.getCliConfigFileUri();
    const cliConfigPath = FileUri.fsPath(cliConfigFileUri);
    let exists = await promisify(fs.exists)(cliConfigPath);
    if (!exists) {
      await this.initCliConfigTo(path.dirname(cliConfigPath));
      exists = await promisify(fs.exists)(cliConfigPath);
      if (!exists) {
        throw new Error(
          `Could not initialize the default CLI configuration file at ${cliConfigPath}.`
        );
      }
    }
  }

  protected async initCliConfigTo(fsPathToDir: string): Promise<void> {
    const cliPath = await this.daemon.getExecPath();
    await spawnCommand(`"${cliPath}"`, [
      'config',
      'init',
      '--dest-dir',
      `"${fsPathToDir}"`,
    ]);
  }

  protected async mapCliConfigToAppConfig(
    cliConfig: DefaultCliConfig
  ): Promise<Config> {
    const { directories, locale = 'en', daemon } = cliConfig;
    const { data, user, downloads } = directories;
    const additionalUrls: Array<string> = [];
    if (cliConfig.board_manager && cliConfig.board_manager.additional_urls) {
      additionalUrls.push(
        ...Array.from(new Set(cliConfig.board_manager.additional_urls))
      );
    }
    const network = Network.parse(cliConfig.network?.proxy);
    return {
      dataDirUri: FileUri.create(data).toString(),
      sketchDirUri: FileUri.create(user).toString(),
      downloadsDirUri: FileUri.create(downloads).toString(),
      additionalUrls,
      network,
      locale,
      daemon,
    };
  }

  protected fireConfigChanged(config: Config): void {
    this.configChangeEmitter.fire(config);
    this.notificationService.notifyConfigChanged({ config });
  }

  protected fireInvalidConfig(): void {
    this.notificationService.notifyConfigChanged({ config: undefined });
  }

  protected async updateDaemon(
    port: string | number,
    config: DefaultCliConfig
  ): Promise<void> {
    const client = this.createClient(port);
    const req = new MergeRequest();
    const json = JSON.stringify(config, null, 2);
    req.setJsonData(json);
    console.log(`Updating daemon with 'data': ${json}`);
    return new Promise<void>((resolve, reject) => {
      client.merge(req, (error) => {
        try {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        } finally {
          client.close();
        }
      });
    });
  }

  protected async writeDaemonState(port: string | number): Promise<void> {
    const client = this.createClient(port);
    const req = new WriteRequest();
    const cliConfigUri = await this.getCliConfigFileUri();
    const cliConfigPath = FileUri.fsPath(cliConfigUri);
    req.setFilePath(cliConfigPath);
    return new Promise<void>((resolve, reject) => {
      client.write(req, (error) => {
        try {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        } finally {
          client.close();
        }
      });
    });
  }

  private createClient(port: string | number): SettingsServiceClient {
    // https://github.com/agreatfool/grpc_tools_node_protoc_ts/blob/master/doc/grpcjs_support.md#usage
    const SettingsServiceClient = grpc.makeClientConstructor(
      // @ts-expect-error: ignore
      serviceGrpcPb['cc.arduino.cli.settings.v1.SettingsService'],
      'SettingsServiceService'
    ) as any;
    return new SettingsServiceClient(
      `localhost:${port}`,
      grpc.credentials.createInsecure()
    ) as SettingsServiceClient;
  }
}
